Skip to content

fix(cliffs): multisample offsets are in GRID UNITS - #18 root cause - #83

Merged
wormeyman merged 1 commit into
mainfrom
cliffs-multisample-grid-units-18
Aug 2, 2026
Merged

fix(cliffs): multisample offsets are in GRID UNITS - #18 root cause#83
wormeyman merged 1 commit into
mainfrom
cliffs-multisample-grid-units-18

Conversation

@wormeyman

Copy link
Copy Markdown
Owner

Root cause of #18, found and fixed.

vulcanus-multisample-NOTES.md proved multisample(e,dx,dy) == e(x+dx, y+dy) at 150/150 comparisons, residual exactly zero. That is correct - for calculate_tile_properties, whose noise program has a 1-tile grid. It was never checked in any other channel, and the primitive's own docs say it evaluates "in a separate noise program with a larger grid" whose "sub-grids are copied to the main program". That phrase is load-bearing.

Asked through the cliff generator (grid = the 4-tile corner lattice) by routing a probe onto cliff_elevation with the rule collapsed, so cliffs mark exactly where the field crosses 71:

probe column
x 70 baseline
multisample(x, 0, 0) 70 identity
multisample(x, 4, 0) 54 shifted 16 tiles, not 4
multisample(x, 0, 4) 70 null control

dx = 4 moves the field by 4 × the 4-tile grid step. The true rule is e(x + dx*G, y + dy*G) for the calling program's grid G.

So vulcanus_basalt_lakes_multisample's min over {0,1}² spans 4 tiles for cliffs and 1 tile for every per-tile consumer. min is erosion, so the cliff channel's elevation is far smoother. The port used the 1-tile field for both - too rough, over-placing ~40%.

Result

before after
wrong orientation 175 = 12.5% 37 = 2.4%
recall 0.806 / 0.938 / 0.853 1.000 / 0.973 / 0.965
[0,0] (worst region) 29.8% wrong 2.5%, recall 1.000
level sweep ratio 1.20-1.49 below elevation 120 1.00-1.09 at every level

VulcanusElevation now exposes cliffElevation beside elevation. Both hang off one stack and share every sub-expression below the multisample, so the cost is a second memo table - a private DAG for cliffs was tried first and is much dearer. Do not collapse them back together: they are different fields, not a cache miss.

Nothing here refutes the multisample port or the per-tile consumers - calculate_tile_properties and the tile renderer both live in the 1-tile channel where e(x+dx, y+dy) is exactly right.

Specs are inverted, not deleted

The inversions are the evidence: the s=0 smoothing arm is now exact, all four collapsed arms are exact, the level sweep matches at every level, and the corner-fields substitutions now move cells - those fixtures are the tile channel, so they are the right numbers for the wrong consumer, and their agreeing for months is how this stayed invisible. cliffResidual's Vulcanus block is retired: its mismatched population is now too small to compare.

The lesson

The third form of the same trap: not a fixture captured at the wrong site (#70's grid_offset), not a wrong value - a fixture captured through the wrong channel, agreeing with a port that made the same mistake. Ask which code path consumes a value, not only which coordinates it is sampled at. And no sweep inside the port could have found it: the smoothing, band and rule sweeps all searched a family that shared the defect.

Local test note

pnpm vp check: 310 files clean. The full suite currently shows 8 timeout-only failures on clean main as well - verified by stashing and re-running - after hours of Factorio captures loaded this machine. No assertion failures on either. CI is the arbiter.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GRx1CU29RKLsRuUAErEDRB

)

vulcanus-multisample-NOTES proved multisample(e,dx,dy) == e(x+dx, y+dy) at
150/150 comparisons with residual exactly zero. That is correct - for
LuaSurface.calculate_tile_properties, whose noise program has a 1-TILE grid.
It was never checked in any other channel, and the primitive's own docs say it
evaluates "in a separate noise program with a larger grid" whose "sub-grids are
copied to the main program". That phrase is load-bearing.

Asked through the CLIFF GENERATOR, whose grid is the 4-tile corner lattice, by
routing a probe onto cliff_elevation with the rule collapsed so cliffs mark
exactly where the field crosses 71:

  x                      column 70   baseline
  multisample(x, 0, 0)   column 70   identity
  multisample(x, 4, 0)   column 54   shifted 16 TILES, not 4
  multisample(x, 0, 4)   column 70   null control

dx = 4 moves the field by 4 x the 4-tile grid step. So the true rule is
e(x + dx*G, y + dy*G) for the CALLING program's grid G.

Consequence: vulcanus_basalt_lakes_multisample's min over {0,1}x{0,1} spans 4
tiles for cliffs and 1 tile for every per-tile consumer. min is erosion, so the
cliff channel's elevation is much smoother. The port used the 1-tile field for
both, making the cliff elevation too rough and over-placing by ~40%.

                        before            after
  wrong orientation     175 = 12.5%       37 = 2.4%
  recall            0.806/0.938/0.853  1.000/0.973/0.965
  [0,0] worst region    29.8% wrong       2.5%, recall 1.000
  level sweep ratio  1.20-1.49 below 120  1.00-1.09 at EVERY level

VulcanusElevation now exposes cliffElevation beside elevation. Both hang off one
stack and share every sub-expression below the multisample, so the cost is a
second memo table - a private DAG for cliffs was tried first and is much dearer.
Do not collapse them back together: they are different fields, not a cache miss.

Nothing here refutes the multisample port or the per-tile consumers.
calculate_tile_properties and the tile renderer both live in the 1-tile channel
where e(x+dx, y+dy) is exactly right.

Specs that pinned the defect are inverted rather than deleted, because the
inversions are the evidence: the s=0 smoothing arm is now exact, all four
collapsed arms are exact, the level sweep matches at every level, and the
corner-fields substitutions now MOVE cells - those fixtures are the tile
channel, so they are the right numbers for the wrong consumer, and their
agreeing for months is how the wrong channel stayed invisible. cliffResidual's
Vulcanus block is retired: its mismatched population is now too small to
compare.

The lesson is the third form of the same trap. Not a fixture captured at the
wrong SITE (#70's grid_offset), not a wrong value - a fixture captured through
the wrong CHANNEL, agreeing with a port that made the same mistake. Ask which
code path CONSUMES a value, not only which coordinates it is sampled at. And
note that no sweep inside the port could have found this: the smoothing,
band and rule sweeps all searched a family that shared the defect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GRx1CU29RKLsRuUAErEDRB
@wormeyman
wormeyman merged commit 0f0eee6 into main Aug 2, 2026
2 checks passed
wormeyman added a commit that referenced this pull request Aug 2, 2026
An audit after the multisample grid fix. Several documents still asserted the
opposite of what is now measured, and three of them were TITLES - the lines a
reader trusts fastest.

- `cliffs-NOTES.md` gains a STATUS banner at the top. The root cause sat at
  line 636 under 635 lines of superseded investigation, so a cold reader met
  the wrong conclusions first. The banner carries the current recall and
  orientation numbers, points at the ROOT CAUSE section, and names the specific
  stale figures below it (the ~90%, the 12.5%/29.8%/8.1%/11.7% tables, the
  0.806/0.938/0.853 recalls) as pre-fix.
- Two headings there were plainly wrong: "validated ~90% tile-for-tile", and
  "Validation result: EXACT since 2026-07-30 (the residual is resolved)" -
  which was only ever true of Nauvis.
- `vulcanus-cliffs-NOTES.md` gains the same banner, and its
  "## The residual is in the RULE, not the fields" section is marked FALSIFIED.
  Its experiment and numbers are sound; the verdict is not. The fixture it
  substitutes came through calculate_tile_properties (1-tile grid) while the
  cliff generator reads a 4-tile grid, so the fixture and the port shared the
  mistake and could never disagree.
- `client-preview-ROADMAP.md` said the residual "is now believed to be a RULE
  error rather than a field error (PR #57)". It was a field error. Corrected
  with why PR #57's substitution missed it.
- `vulcanusCliffCornerFields.spec.ts`'s doc header and
  `vulcanusOreCliffSeparation.spec.ts`'s describe name both still claimed the
  fields are exact and the residual is in the rule. Both now say the fields are
  exact IN THE TILE CHANNEL, which is the accurate and more useful statement.
- CLAUDE.md's spec-file count was 143; it is 152.

No behaviour change. `pnpm run verify`: 310 files clean, 1306 passed / 3
skipped, preview 12 passed - the suite is green on an unloaded machine, which
also confirms the earlier local timeouts were load and not a regression.


Claude-Session: https://claude.ai/code/session_01GRx1CU29RKLsRuUAErEDRB

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
wormeyman added a commit that referenced this pull request Aug 2, 2026
…#84) (#87)

#83 left an assumption behind: that cliffs are the only consumer reading a
multisample-bearing field on a grid other than 1 tile. That was never measured,
which is exactly the status the cliff case itself had before #83. Closes #84
item 4.

The surface is much smaller than "audit every consumer", and that is the first
finding. multisample appears in factorio-data @ 2.1.12 in exactly ONE place -
vulcanus_basalt_lakes_multisample, used only by vulcanus_elev. No other planet
uses the primitive. So the audit reduces to "who consumes Vulcanus elevation":

| consumer                  | reads elevation? | grid | how              |
| ------------------------- | ---------------- | ---- | ---------------- |
| cliff generator           | yes              | 4    | measured, #83    |
| tile generator (19 range) | yes, via elev    | 1    | measured, here   |
| vulcanus_temperature      | yes              | 1    | same program     |
| tungsten/coal/calcite/SA  | no               | n/a  | read the Lua     |
| rocks, geyser             | no               | n/a  | read the Lua     |

The resource row had to be checked rather than assumed, because the generic path
DOES couple to elevation: starting_resources_lake_mask = clamp((elevation-1)/10,
0, 1) feeds starting_patches' spot_favorability_expression, and CLAUDE.md flags
that coupling as exactly what changed at 2.1.9. Vulcanus does not take that
path - each of its four resources uses its own vulcanus_*_region expression, and
those four definitions contain 0 references to elevation.

The tile generator reads grid 1, measured against the game. Substituting the
cliff generator's 4-tile elevation into the tile resolver, over the 381 oracle
positions:

|                          | grid 1 (ships) | grid 4  |
| ------------------------ | -------------- | ------- |
| tile-name agreement      | 0.9816         | 0.8609  |
| lava misclassifications  | 0 / 381        | 27 / 381 |

46 tiles named wrongly, and the binary lava call - the only thing the cliff
collision rejection reads - goes from exact to 27 wrong. The metric is
demonstrably sensitive to the swap, which is the part worth insisting on: PR
#57's substitution failed precisely because "nothing changed" could not be
distinguished from "the substitution never ran". Both arms carry that guard
explicitly.

Side result: this clears the lava perimeter that costs 13 real cliffs their
placement (#86). Reading the other elevation channel makes lava dramatically
worse, not better, so the perimeter error is not a channel mistake - it is
somewhere else in vulcanusCatalog.

Also asserts the max(-500, ...) clamp never bites at these positions, so the
swap is exactly "the same field at grid 4" rather than something subtly else.


Claude-Session: https://claude.ai/code/session_01WRKSNgkidxc6daeHGJHqpt

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
wormeyman added a commit that referenced this pull request Aug 2, 2026
cliffOrientationOracle.spec.ts counts the residual and bounds it. This pins what
it looks like, because the shape is the lead and a change in shape is a change in
cause even when the count holds.

Measured after #83 (multisample grid), #86 (lava rejection) and #90 (raw
collision box):

- 37 of 1531 matched cells, and all 37 differ in EXACTLY ONE edge. Not one
  two-edge difference survives; before #83 the dominant mode was two edges
  (125 of 175), a whole corner on the wrong side of a band.
- Every one is an OVER-detection: the game reports a -to-none orientation and the
  port reports a crossing on that edge, never the reverse.
- Spread over all four edges (L11/R6/T7/B13) and all three regions (7/26/4), so
  it is not a directional off-by-one.

Two candidate causes are already eliminated, which is why the shape is worth
pinning rather than re-deriving next session:

- crossesCliff is EXACT. Disassembled at 0x10160c914 under 2.1.12 (the VA in
  cliffs-NOTES.md had moved); cliffPlacement.ts reproduces it line for line
  including the a<0||b<0 early-out, the boundary<e0 check, and the strict >0.5
  gate and strict crossing comparisons. No >=-vs-> slip exists to find.
- cliffiness_basic is EXONERATED. Substituting the game's own corner cliffiness
  leaves the count at exactly 37/1531.

So the residual is in the grid-4 cliff-elevation field - the one input in the
chain with no direct per-corner oracle. A single-edge, strictly one-directional
over-detection is what a small positive field offset looks like.

The direction assertion is the load-bearing one: if under-detections ever appear,
the cause has changed and that reading is dead.


Claude-Session: https://claude.ai/code/session_01WRKSNgkidxc6daeHGJHqpt

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
wormeyman added a commit that referenced this pull request Aug 3, 2026
)

* feat(cliffs): port the ORE -> CLIFF rejection (#84 item 1)

#99 characterised the rule and stopped short of porting it, flagging one open
sub-question: whether driving it from our own resource model, rather than the
game's entities, is accurate enough. It is - and it costs exactly one cell.

Scored across all three oracle regions, driving `makeVulcanusOreRejection` off
the same field stack the ore overlay paints from:

| region | game | placed | fires | false rejections | surplus |
| --- | --- | --- | --- | --- | --- |
| [0,0] | 283 | 283 | 0 | 0 | 2 -> 2 |
| [1500,1500] | 885 | 900 | 20 | 0 | 42 -> 22 |
| [-1200,800] | 401 | 387 | 0 | 0 | 1 -> 1 |

Precision at [1500,1500] 0.953 -> 0.975 with the 858 true positives untouched.
**Recall is not touched anywhere**, which was the gate: this rule may only ever
cost precision.

Three variants were scored and the two that lose are kept in the spec rather
than dismissed in a comment, because #88/#90 already paid for that lesson here:

| variant | fires | correct of 31 | false rejections |
| --- | --- | --- | --- |
| base box, ores only (SHIPPED) | 20 | 20 | 0 |
| base box + geyser | 21 | 20 | 1 |
| per-orientation box | 23 | 21 | 2 |

The geyser arm is strictly HARMFUL - one more false rejection and not one
additional correct suppression - so it is implemented behind `includeGeyser`,
defaulting off. The per-orientation rotbb box catches one more true cell and
pays two kept cliffs for it; higher `correct` is exactly the trap.

Not claimed: the mechanism is still open (the disassembly says cliffs are placed
before any resource entity exists), and 11 of the 31 stay unexplained with the
box deliberately not widened to cover them. The spec pins that 11.

Lands as `CliffBands.cellRejects`, an opaque per-cell predicate beside
`tileCollides` - so the shared cliff core stays planet-agnostic, and the model
the specs score is the model the renderer ships. The predicate enumerates no
entities: the overlapping tiles follow in closed form (2 tiles for an ore
against the lava rejection's ~30), guarded by a wider brute-force scan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyN97UwFQmwZs1cg4QHS1c

* test(cliffs): the budget FLIPPED - recall is now the bigger defect (#84)

Every cliff defect found since #18 has been a rule the port over-places without
(lava collision, the rotbb box shape, the ore suppression), so "find another
rejection" has been the shape of the work throughout. After #100 that is no
longer where the error is.

| region | surplus | missing | lava-killed | ore-killed | never generated |
| --- | --- | --- | --- | --- | --- |
| [0,0] | 2 | 2 | 2 | 0 | 0 |
| [1500,1500] | 22 | 27 | 3 | 0 | 24 |
| [-1200,800] | 1 | 15 | 1 | 0 | 14 |
| total | 25 | 44 | 6 | 0 | 38 |

**The port now misses more cells than it over-places, 44 to 25**, and 38 of the
44 are cells the crossings stage never produces at all - a different defect in a
different part of the port from everything solved so far.

`[0,0]` generates every cell the game does: its whole miss is the two the lava
rejection took, and `neverGenerated` is zero there. All 38 sit in the two
far-field regions, which agrees with #93 finding the port exact at [0,0] and
[-1200,800] at cliff_smoothing = 0 and still wrong at [1500,1500].

Also closes item 3 (the entity half of `Surface::wouldCollide`) UNPORTED, by
size rather than by difficulty - the same move that retired fixImpossibleCells
as a suspect. It is a rejection, and rejections can only remove cells: total
surplus is 25, which bounds what rocks and craters together could ever be worth
against a 44-cell recall gap they cannot touch.

The crater arm is settled exactly, since craters are already in the fixtures:
all 8 sit in [-1200,800] and not one touches a cell the port over-places, nor
any cliff the game kept. Worth zero. The rock arm needs no fixture - the ceiling
covers it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyN97UwFQmwZs1cg4QHS1c

* test(cliffs): the recall gap was a QUERY-WINDOW ARTIFACT - recall is 0.9961 (#84)

Supersedes this branch's first commit, whose central claim was wrong.

`find_entities_filtered` selects entities whose BOUNDING BOX touches the query
area; `placedCells` emits cells whose CENTRE lies inside it. Different inclusion
rules, so the fixtures carry cliffs centred just outside the box and every one
has been scored as a miss.

| region | game rows | centred inside | centred OUTSIDE |
| --- | --- | --- | --- |
| [0,0] | 283 | 283 | 0 |
| [1500,1500] | 885 | 861 | 24 |
| [-1200,800] | 401 | 387 | 14 |

That is 38 cells - the entire apparent recall gap - and the port places 38 of 38
once the query box includes their centres. Every one is an agreement being
scored as a failure. The widening arm is the load-bearing one: "we never looked
there" alone is equally consistent with the port being wrong.

Corrected budget, both sides scored alike:

| region | game | port | matched | surplus | missing |
| --- | --- | --- | --- | --- | --- |
| [0,0] | 283 | 283 | 281 | 2 | 2 |
| [1500,1500] | 861 | 880 | 858 | 22 | 3 |
| [-1200,800] | 387 | 387 | 386 | 1 | 1 |
| total | 1531 | 1550 | 1525 | 25 | 6 |

**Recall 0.9961, precision 0.9839.** The 0.972 recall in the notes divided the
same 1525 matches by 1569 rather than 1531 - the match count was never wrong,
only the denominator.

All 6 missing cells are ones our own lava rejection removed; there is no cell
the port simply fails to generate. So precision is the only real defect left.

Consequently item 3 (the entity half of Surface::wouldCollide) is RE-OPENED.
The earlier commit closed it by size, arguing a rejection cannot help a 44-cell
recall gap - that argument died with the gap. With recall at 0.9961 the dominant
defect is the 25 surplus cells, which is exactly what a rejection removes.

The crater arm stays settled at zero (all 8 sit in [-1200,800], none touches a
surplus cell). The rock arm has no oracle capture at all, so capturing one is
the next step - now with a 25-cell target rather than a ceiling against it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyN97UwFQmwZs1cg4QHS1c

* test(cliffs): the rock arm fails on the mechanism's own geometry (#84)

Completes the item 3 thread in this branch rather than opening a second PR for
it, since the previous commit had just re-opened it.

No rock capture is needed to kill it. `computeInternal` runs `generateCliffs`
before `generateEntities`, and `apply` runs `applyCliffs` (+124) before
`applyEntities` (+164), so within a chunk no rock exists when the cliff is
applied. A rock can only block a cliff from an ALREADY-GENERATED NEIGHBOUR,
which confines the whole mechanism to cells near a 32-tile chunk border.

| | n | near chunk border |
| --- | --- | --- |
| surplus | 25 | 11 = 44.0% |
| matched | 1525 | 673 = 44.1% |

The base rate to three significant figures. The surplus has no chunk-border
character at all, so the one geometry the mechanism is confined to is not where
the errors are.

The direct overlap test agrees and is deliberately the weaker arm: 3 of 25
surplus cells overlap a modelled rock against a 6.6% base rate (~1.7 expected),
which is nothing - and our rock placement is a salt-dependent roll whose
individual positions are unreliable exactly as the geyser's were in #100.

So item 3 explains approximately none of the 25, and is closed on the
mechanism's geometry rather than on the ceiling argument that died with the
recall gap. Remaining unexplained: 25 surplus, 6 missing (all lava-rejection
over-rejections), 33 wrong orientations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyN97UwFQmwZs1cg4QHS1c

* test(cliffs): the orientation residual is not a boundary tie (#84)

Stacked on the error-budget branch.

The residual's known shape - exactly one edge per wrong cell, always an
OVER-detection - has an obvious cheap explanation that turns out to be wrong,
and ruling it out eliminates a whole class of cause.

`crossesCliff` decides on the sign of `elevation - boundary`. If an endpoint sat
within float noise of a band boundary, the ~1e-6 our fields agree to would flip
the crossing, and the residual would be an irreducible precision limit with
nothing to fix.

Measured: every crossing edge in a wrong cell sits at least 0.205 from its
boundary, median ~9.9 - four to seven orders of magnitude clear of float noise.
For the game to disagree its elevation must differ from ours by more than 0.2,
which is a real field or rule difference.

Non-vacuity: the overall minimum across all 2,920 crossing edges is 6.4e-3,
thirty times tighter, so "far from the boundary" is a property of the wrong
cells and not of the sample.

Also pins WHICH channel the corner fixture holds, as a number rather than as
prose: against our per-tile elevation the worst corner differs by 4.8e-2;
against the grid-4 cliff channel the generator actually reads, by 96.09. #83 was
the most expensive mistake this subsystem has made and prose is not a guard.

The gap that leaves is the point: the grid-4 cliff-elevation channel has NO
per-corner oracle, is the only placement input never checked against the game
corner by corner, and after the margin result is the only remaining candidate
that could move an endpoint the required 0.2. Capturing it is the next step -
and not via calculate_tile_properties, which is the 1-tile program that produced
the wrong-channel fixture in the first place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GyN97UwFQmwZs1cg4QHS1c

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant